Skip to content

Adding memory card support to psyqo#2042

Open
nicolasnoble wants to merge 11 commits into
grumpycoders:mainfrom
nicolasnoble:psyqo-memorycard
Open

Adding memory card support to psyqo#2042
nicolasnoble wants to merge 11 commits into
grumpycoders:mainfrom
nicolasnoble:psyqo-memorycard

Conversation

@nicolasnoble

Copy link
Copy Markdown
Member

No description provided.

nicolasnoble and others added 6 commits June 22, 2026 13:13
Inverts the existing sjis-table.h decode table into a Unicode -> Shift-JIS
mapping, so code that needs to write Shift-JIS (memory card save titles and
the like) can encode from UTF-8 or ASCII instead of hand-rolling a partial
table. Ships the generator, the generated table (sorted for binary search),
and a small header exposing unicodeToSjis, a UTF-8 string encoder, and a
fullwidth-title helper matching the BIOS save-title convention. Verified by
round-tripping encoded output back through the existing Shift-JIS decoder.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
Adds a PSYQo memory card driver in two layers: a low level MemoryCard
transport (the raw 128-byte sector protocol over SIO0, interrupt driven,
exposed as callback, blocking, awaiter and TaskQueue forms) and a
Sony-compatible MemoryCardFileSystem on top of it (format, list, read, write
and delete files that the retail BIOS manager shows with their title and
icon, and can copy or delete itself).

The controllers and the card share the single SIO0 bus and cannot drive it at
the same time, and a card transaction can span several frames, so an SIO0Bus
ownership lock is held for the whole transaction rather than per sector;
AdvancedPad consults it and stands down its frame polling while a card
operation is in flight. Because of this the driver only works alongside
AdvancedPad, which drives SIO0 directly; using SimplePad (the BIOS-driven
pad) at the same time touches the bus outside the lock and is undefined.

Save titles are supplied as UTF-8 and encoded to the Shift-JIS field the BIOS
displays, with printable ASCII promoted to its fullwidth form. Includes a
worked example exercising format, write, read-back and delete.

Co-authored-by: Matt Hadden <forceh2010@gmail.com>
Co-authored-by: Bandwidth <jracek@honzuvkod.dev>
Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
The SIO0 card protocol is timing sensitive within a single sector transfer:
the bytes must flow without interruption, and the card has no dependable
acknowledge interrupt to lean on. So a sector is now a busy-polled exchange
run with all interrupts disabled (fastEnterCriticalSection), polling the
latched acknowledge bit, and the controller-IRQ state machine is removed
entirely. The per-sector transfer is intentionally synchronous: it is the
atomic unit that the timer-driven filesystem layer chains across frames.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
…hine

A memory card is itself a small state machine whose timing varies by card and
by phase, and reading the data register before the chip has advanced is a
no-op rather than a stale byte. So a sector transfer now clocks each byte and
then polls the card's output until it reaches the expected reply: this
self-synchronizes to the card's per-state timing without tracking a fixed
full-duplex offset. The identifier and command-acknowledge bytes are the slow,
variable states that are polled; the data phase is regular and
acknowledge-paced. Every protocol byte is named rather than a hanging hex
literal, and the inner transfer runs with interrupts disabled.

The read path follows an inner loop verified on hardware; the write path
mirrors its structure over the standard protocol and still needs validation on
a real card.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
The memory card filesystem now follows the same asynchronous contract as
CDRomDevice. A whole operation is a transaction spanning many sector transfers
across several frames, so each is driven by a main-loop timer (never an
interrupt): one sector is transferred per tick and the game keeps running in
between, and only one operation runs at a time. Every operation has a callback
form (the basis: it takes a GPU to arm the timer and reports its result to an
eastl::function during pumping) and a *Blocking(GPU&) form that pumps until it
completes. The SIO0 bus is owned for the whole transaction, so AdvancedPad
stands down for its full duration.

Internally each operation is a small state machine over shared phases (read the
directory, write the directory, read or write data) advanced one sector per
tick. The example exercises format, write, read-back, list and delete.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
Testing the memory card driver on real hardware surfaced a set of fixes plus
assorted cleanups across the transport and filesystem layers. The main
correctness fix is an inter-transaction recovery delay before each port
(re)select: the retail BIOS gets that recovery for free by driving one sector
per vblank, but back-to-back transactions can reselect a slow third-party card
too soon and read stale data that still carries a valid checksum. The
port-select sequence is also corrected to enable the transmitter and the
acknowledge IRQ before the bus-settle delay, so a present card is not misread
as absent on the first byte.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds memory card support to psyqo: SIO0 arbitration, low-level sector I/O, SJIS title encoding utilities, a filesystem wrapper, and an interactive example that exercises probe, format, read, write, list, delete, and metadata flows.

Changes

Memory Card subsystem

Layer / File(s) Summary
SIO0 bus arbiter and pad guard
src/mips/psyqo/sio0-bus.hh, src/mips/psyqo/src/advancedpad.cpp
Introduces psyqo::SIO0Bus with re-entrant ownership tracking and an RAII lock, and makes pad polling stop while the bus is owned.
MemoryCard API declarations
src/mips/psyqo/memory-card.hh
Declares psyqo::MemoryCard with error codes, port selection, geometry constants, blocking and callback sector APIs, task scheduling, and coroutine awaiters.
MemoryCard driver implementation
src/mips/psyqo/src/memory-card.cpp
Implements SIO0 initialization, IRQ and polling sector transfers, retry handling, probing, blocking wrappers, and async sector operations.
SJIS encoding utilities
src/mips/common/util/gen-sjis-encode-table.py, src/mips/common/util/utf8-decode.h, src/mips/common/util/sjis-fullwidth-ascii.h, src/mips/common/util/sjis-encode.h
Adds the reverse SJIS table generator, UTF-8 decoding, fullwidth ASCII mapping, and inline UTF-8→SJIS title encoding helpers.
Title encoder registration
src/mips/common/util/sjis-title-encoder.h, src/mips/psyqo/src/sjis-title-encoder.cpp, src/mips/psyqo/src/sjis-full-title-encoder.cpp
Declares the active title encoder interface and adds translation units that bind it to the ASCII-based encoder by default or to the full encoder on registration.

Filesystem and example

Layer / File(s) Summary
MemoryCardFileSystem API and engine declarations
src/mips/psyqo/memory-card-filesystem.hh
Declares the filesystem wrapper, its metadata structs, async and blocking operations, helper routines, and internal transaction state.
MemoryCardFileSystem state machine
src/mips/psyqo/src/memory-card-filesystem.cpp
Implements directory parsing, chain walking, async transaction sequencing, file read/write/delete handling, and the blocking wrappers.
Memory card interactive test example
src/mips/psyqo/examples/memorycard/Makefile, src/mips/psyqo/examples/memorycard/memorycard.cpp
Adds the buildable memory-card demo, its bounded log and step harness, startup wiring, per-frame rendering, and entry point.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The provided description is absent, so it doesn't add meaningful context about the changeset. Add a short description of the new memory card API, filesystem, and example changes.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding memory card support to psyqo.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/mips/psyqo/src/memory-card.cpp (1)

718-758: 🩺 Stability & Availability | 🔵 Trivial

Async sector transfers should acquire SIO0Bus for their in-flight duration to prevent AdvancedPad from corrupting the transfer.

The callback variants of readSector and writeSector return immediately and let the interrupt state machine run across frames without acquiring SIO0Bus, relying entirely on the caller to hold the bus. While the header comment documents this caller responsibility and the current codebase respects it (the filesystem transaction holds the outer lock), this is a footgun waiting for a direct caller to bypass the safeguard. Acquire SIO0Bus in startAsyncAttempt and release in onAsyncAttemptDone; the re-entrant counter tolerates nested acquisition from the filesystem's outer lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/psyqo/src/memory-card.cpp` around lines 718 - 758, The async sector
transfer methods `readSector` and `writeSector` initiate async operations via
`startAsyncAttempt` without acquiring the SIO0Bus lock, relying entirely on the
caller to hold it and creating a footgun for direct callers. To fix this,
acquire SIO0Bus at the beginning of `startAsyncAttempt` method and release it in
the `onAsyncAttemptDone` method that completes the async operation. Since
SIO0Bus uses a re-entrant counter, nested acquisition from the filesystem's
outer lock will be safely tolerated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mips/common/util/gen-sjis-encode-table.py`:
- Around line 17-19: The if, elif, and else statements on lines 17-19 contain
multiple statements on a single line (condition and assignment together),
violating Ruff E701/E702 rules. Split each statement by moving the assignment of
base and lead_hi to a new line below the if/elif/else condition, so each line
contains only one statement. Apply the same fix to line 46 which has a similar
multi-statement violation.

In `@src/mips/psyqo/sio0-bus.hh`:
- Around line 46-47: The documentation comment at lines 46-47 is misleading
about how the re-entrant count works. The current wording implies that the
low-level driver function irqTransferBlocking() also acquires and releases the
lock, but it never actually calls acquire() or release(). Only the filesystem
layer (MemoryCardFileSystem) holds the lock across multiple sector operations.
Update the documentation to clarify that the re-entrant count exists because
filesystem operations hold the lock across all their sector transfers, not
because the blocking transfer function itself participates in arbitration. Make
it clear that only the filesystem operation caller (not the driver) acquires and
holds the bus.

In `@src/mips/psyqo/src/memory-card-filesystem.cpp`:
- Around line 229-238: The Phase::WriteDir case commits dirty directory entries
by index order, but this creates a safety issue with the multi-entry chain logic
built elsewhere (around lines 347-377) which requires the first entry of a chain
to be written last. Currently, if power loss occurs after writing the first
entry in a chain but before all linked entries are written, an incomplete file
chain can be exposed on the card. Modify the WriteDir phase to commit directory
entries in a specific order where the first entry of any chain is written LAST,
after all its linked entries are already committed. This ensures the "first
entry last" rule is applied consistently throughout the write and overwrite
paths, preventing partially committed files from being visible on interruption.
- Around line 444-455: The block chain traversal loop exits when the block value
goes out of range but still proceeds to the ReadTitle phase as if it completed
successfully. Add validation after the while loop to check if the chain
terminated cleanly by verifying that either the 0xffff terminator was
encountered or the chain length reached its maximum. If the loop exited due to
block going out of bounds without a clean termination, set m_result to
Error::BadData and return StepResult::Done instead of proceeding to the
ReadTitle phase.
- Around line 128-133: The totalBytes calculation at line 128 uses uint32_t
which can overflow when m_dataLen is large, allowing the FileTooLarge check to
be bypassed silently. Before computing totalBytes, check if adding
m_headerFrames multiplied by MemoryCard::c_sectorSize and m_dataLen would
overflow a uint32_t, or alternatively compute totalBytes using a larger type
like uint64_t and verify the result does not exceed the uint32_t maximum before
proceeding with the block-count calculation and the existing bounds check.

---

Nitpick comments:
In `@src/mips/psyqo/src/memory-card.cpp`:
- Around line 718-758: The async sector transfer methods `readSector` and
`writeSector` initiate async operations via `startAsyncAttempt` without
acquiring the SIO0Bus lock, relying entirely on the caller to hold it and
creating a footgun for direct callers. To fix this, acquire SIO0Bus at the
beginning of `startAsyncAttempt` method and release it in the
`onAsyncAttemptDone` method that completes the async operation. Since SIO0Bus
uses a re-entrant counter, nested acquisition from the filesystem's outer lock
will be safely tolerated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f213614b-0fe8-4a0f-b059-576161ffe694

📥 Commits

Reviewing files that changed from the base of the PR and between 221e96b and 79a12bc.

📒 Files selected for processing (11)
  • src/mips/common/util/gen-sjis-encode-table.py
  • src/mips/common/util/sjis-encode-table.h
  • src/mips/common/util/sjis-encode.h
  • src/mips/psyqo/examples/memorycard/Makefile
  • src/mips/psyqo/examples/memorycard/memorycard.cpp
  • src/mips/psyqo/memory-card-filesystem.hh
  • src/mips/psyqo/memory-card.hh
  • src/mips/psyqo/sio0-bus.hh
  • src/mips/psyqo/src/advancedpad.cpp
  • src/mips/psyqo/src/memory-card-filesystem.cpp
  • src/mips/psyqo/src/memory-card.cpp

Comment on lines +17 to +19
if index < 0x1100: base, lead_hi = 0x100, 0x80
elif index < 0x2100: base, lead_hi = 0x1100, 0x90
else: base, lead_hi = 0x2100, 0xe0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve Ruff E701/E702 inline-statement violations.

Line 17, Line 18, Line 19, and Line 46 use multi-statements on one line and currently fail the configured lint rules.

💡 Suggested cleanup
 def index_to_sjis(index):
     if index < 0x100:
         return index
-    if index < 0x1100:   base, lead_hi = 0x100,  0x80
-    elif index < 0x2100: base, lead_hi = 0x1100, 0x90
-    else:                base, lead_hi = 0x2100, 0xe0
+    if index < 0x1100:
+        base, lead_hi = 0x100, 0x80
+    elif index < 0x2100:
+        base, lead_hi = 0x1100, 0x90
+    else:
+        base, lead_hi = 0x2100, 0xe0
@@
         tok = "{0x%04x, 0x%04x}, " % (uni, sjis)
         if len(line) + len(tok) > 116:
-            f.write(line.rstrip() + "\n"); line = "    "
+            f.write(line.rstrip() + "\n")
+            line = "    "

Also applies to: 46-46

🧰 Tools
🪛 Ruff (0.15.18)

[error] 17-17: Multiple statements on one line (colon)

(E701)


[error] 18-18: Multiple statements on one line (colon)

(E701)


[error] 19-19: Multiple statements on one line (colon)

(E701)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/common/util/gen-sjis-encode-table.py` around lines 17 - 19, The if,
elif, and else statements on lines 17-19 contain multiple statements on a single
line (condition and assignment together), violating Ruff E701/E702 rules. Split
each statement by moving the assignment of base and lead_hi to a new line below
the if/elif/else condition, so each line contains only one statement. Apply the
same fix to line 46 which has a similar multi-statement violation.

Source: Linters/SAST tools

Comment on lines +46 to +47
* The count is re-entrant: a filesystem operation holds it across all of its
* sector transfers even though each blocking sector transfer also takes it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether the low-level MemoryCard driver ever acquires SIO0Bus.
echo "=== SIO0Bus references across psyqo ==="
rg -nH -C2 'SIO0Bus' src/mips/psyqo
echo "=== sio0-bus.hh include in memory-card.cpp? ==="
rg -nH 'sio0-bus' src/mips/psyqo/src/memory-card.cpp || echo "no include"

Repository: grumpycoders/pcsx-redux

Length of output: 2140


🏁 Script executed:

sed -n '40,60p' src/mips/psyqo/sio0-bus.hh

Repository: grumpycoders/pcsx-redux

Length of output: 1114


🏁 Script executed:

ast-grep outline src/mips/psyqo/src/memory-card.cpp | head -50

Repository: grumpycoders/pcsx-redux

Length of output: 210


🏁 Script executed:

wc -l src/mips/psyqo/src/memory-card.cpp && head -100 src/mips/psyqo/src/memory-card.cpp

Repository: grumpycoders/pcsx-redux

Length of output: 3633


🏁 Script executed:

rg -nH 'blocking|transfer|readBlock|writeBlock' src/mips/psyqo/src/memory-card.cpp | head -30

Repository: grumpycoders/pcsx-redux

Length of output: 1536


🏁 Script executed:

sed -n '400,430p' src/mips/psyqo/src/memory-card.cpp

Repository: grumpycoders/pcsx-redux

Length of output: 1282


🏁 Script executed:

sed -n '750,800p' src/mips/psyqo/src/memory-card.cpp && echo "---" && rg 'acquire|release' src/mips/psyqo/src/memory-card.cpp

Repository: grumpycoders/pcsx-redux

Length of output: 2615


Correct documentation: the low-level driver does not acquire the SIO0Bus.

The blocking sector transfer function (irqTransferBlocking()) in src/mips/psyqo/src/memory-card.cpp never calls acquire() or release(). The re-entrant count exists because the filesystem layer (MemoryCardFileSystem) holds the lock across multiple sector operations, not because blocking transfers self-arbitrate.

The current wording at lines 46–47 is misleading: "a filesystem operation holds it across all of its sector transfers even though each blocking sector transfer also takes it" falsely implies the low-level driver participates in arbitration. Update the documentation to clarify that only the filesystem layer (the operation caller) acquires and holds the bus.

The current code is functionally safe (blocking transfers run synchronously on the main thread, and AdvancedPad respects the owned() check), but the contract must be explicit to prevent a future maintainer from incorrectly assuming the driver self-protects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/psyqo/sio0-bus.hh` around lines 46 - 47, The documentation comment
at lines 46-47 is misleading about how the re-entrant count works. The current
wording implies that the low-level driver function irqTransferBlocking() also
acquires and releases the lock, but it never actually calls acquire() or
release(). Only the filesystem layer (MemoryCardFileSystem) holds the lock
across multiple sector operations. Update the documentation to clarify that the
re-entrant count exists because filesystem operations hold the lock across all
their sector transfers, not because the blocking transfer function itself
participates in arbitration. Make it clear that only the filesystem operation
caller (not the driver) acquires and holds the bus.

Comment on lines +128 to +133
uint32_t totalBytes = m_headerFrames * MemoryCard::c_sectorSize + m_dataLen;
m_blocksNeeded = (totalBytes + (MemoryCard::c_blockSize - 1)) >> 13; // ceil over 8192
if (m_blocksNeeded < 1) m_blocksNeeded = 1;
if (m_blocksNeeded > 15) {
m_result = Error::FileTooLarge;
m_phase = Phase::Fail;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard totalBytes against overflow before block-count calculation.

Line 128 computes totalBytes in uint32_t; large dataLen can wrap and bypass the FileTooLarge check, causing silent truncation instead of a deterministic error.

💡 Suggested fix
-            uint32_t totalBytes = m_headerFrames * MemoryCard::c_sectorSize + m_dataLen;
-            m_blocksNeeded = (totalBytes + (MemoryCard::c_blockSize - 1)) >> 13;  // ceil over 8192
+            const uint32_t headerBytes = m_headerFrames * MemoryCard::c_sectorSize;
+            const uint32_t maxCardBytes = 15u * MemoryCard::c_blockSize;
+            if (headerBytes > maxCardBytes || m_dataLen > (maxCardBytes - headerBytes)) {
+                m_result = Error::FileTooLarge;
+                m_phase = Phase::Fail;
+                return;
+            }
+            const uint32_t totalBytes = headerBytes + m_dataLen;
+            m_blocksNeeded = (totalBytes + (MemoryCard::c_blockSize - 1)) >> 13;  // ceil over 8192
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uint32_t totalBytes = m_headerFrames * MemoryCard::c_sectorSize + m_dataLen;
m_blocksNeeded = (totalBytes + (MemoryCard::c_blockSize - 1)) >> 13; // ceil over 8192
if (m_blocksNeeded < 1) m_blocksNeeded = 1;
if (m_blocksNeeded > 15) {
m_result = Error::FileTooLarge;
m_phase = Phase::Fail;
const uint32_t headerBytes = m_headerFrames * MemoryCard::c_sectorSize;
const uint32_t maxCardBytes = 15u * MemoryCard::c_blockSize;
if (headerBytes > maxCardBytes || m_dataLen > (maxCardBytes - headerBytes)) {
m_result = Error::FileTooLarge;
m_phase = Phase::Fail;
return;
}
const uint32_t totalBytes = headerBytes + m_dataLen;
m_blocksNeeded = (totalBytes + (MemoryCard::c_blockSize - 1)) >> 13; // ceil over 8192
if (m_blocksNeeded < 1) m_blocksNeeded = 1;
if (m_blocksNeeded > 15) {
m_result = Error::FileTooLarge;
m_phase = Phase::Fail;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/psyqo/src/memory-card-filesystem.cpp` around lines 128 - 133, The
totalBytes calculation at line 128 uses uint32_t which can overflow when
m_dataLen is large, allowing the FileTooLarge check to be bypassed silently.
Before computing totalBytes, check if adding m_headerFrames multiplied by
MemoryCard::c_sectorSize and m_dataLen would overflow a uint32_t, or
alternatively compute totalBytes using a larger type like uint64_t and verify
the result does not exceed the uint32_t maximum before proceeding with the
block-count calculation and the existing bounds check.

Comment thread src/mips/psyqo/src/memory-card-filesystem.cpp
Comment on lines +444 to +455
while (block >= 1 && block <= 15 && m_chainLen < 16) {
if (visited & (1 << block)) {
m_result = Error::BadData;
return StepResult::Done;
}
visited |= (1 << block);
m_chain[m_chainLen++] = static_cast<uint8_t>(block);
uint16_t next = get16(m_dir[block - 1].bytes + c_offNext);
if (next == 0xffff) break;
block = next + 1;
}
m_phase = Phase::ReadTitle;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject malformed block chains that do not terminate cleanly.

Line 444 exits the traversal when the next block goes out-of-range, but still proceeds as success. That can return partial/truncated data as OK instead of Error::BadData.

💡 Suggested fix
             m_chainLen = 0;
             uint16_t visited = 0;
             int block = first;
+            bool terminated = false;
             while (block >= 1 && block <= 15 && m_chainLen < 16) {
                 if (visited & (1 << block)) {
                     m_result = Error::BadData;
                     return StepResult::Done;
                 }
                 visited |= (1 << block);
                 m_chain[m_chainLen++] = static_cast<uint8_t>(block);
                 uint16_t next = get16(m_dir[block - 1].bytes + c_offNext);
-                if (next == 0xffff) break;
+                if (next == 0xffff) {
+                    terminated = true;
+                    break;
+                }
                 block = next + 1;
             }
+            if (!terminated) {
+                m_result = Error::BadData;
+                return StepResult::Done;
+            }
             m_phase = Phase::ReadTitle;
             return StepResult::Continue;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (block >= 1 && block <= 15 && m_chainLen < 16) {
if (visited & (1 << block)) {
m_result = Error::BadData;
return StepResult::Done;
}
visited |= (1 << block);
m_chain[m_chainLen++] = static_cast<uint8_t>(block);
uint16_t next = get16(m_dir[block - 1].bytes + c_offNext);
if (next == 0xffff) break;
block = next + 1;
}
m_phase = Phase::ReadTitle;
m_chainLen = 0;
uint16_t visited = 0;
int block = first;
bool terminated = false;
while (block >= 1 && block <= 15 && m_chainLen < 16) {
if (visited & (1 << block)) {
m_result = Error::BadData;
return StepResult::Done;
}
visited |= (1 << block);
m_chain[m_chainLen++] = static_cast<uint8_t>(block);
uint16_t next = get16(m_dir[block - 1].bytes + c_offNext);
if (next == 0xffff) {
terminated = true;
break;
}
block = next + 1;
}
if (!terminated) {
m_result = Error::BadData;
return StepResult::Done;
}
m_phase = Phase::ReadTitle;
return StepResult::Continue;
🧰 Tools
🪛 GitHub Check: CodeScene Code Health Review (main)

[warning] 444-444: ❌ New issue: Complex Conditional
psyqo::MemoryCardFileSystem::afterReadDir has 1 complex conditionals with 2 branches, threshold = 2

A complex conditional is an expression inside a branch (e.g. if, for, while) which consists of multiple,
logical operators such as AND/OR. The more logical operators in an expression, the more severe the code
smell.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/psyqo/src/memory-card-filesystem.cpp` around lines 444 - 455, The
block chain traversal loop exits when the block value goes out of range but
still proceeds to the ReadTitle phase as if it completed successfully. Add
validation after the while loop to check if the chain terminated cleanly by
verifying that either the 0xffff terminator was encountered or the chain length
reached its maximum. If the loop exited due to block going out of bounds without
a clean termination, set m_result to Error::BadData and return StepResult::Done
instead of proceeding to the ReadTitle phase.

The directory parser only knew about in-use (0x51/0x52/0x53) and
never-allocated (0xa0) blocks, which is incomplete: deleting a file marks its
blocks 0xa1/0xa2/0xa3 (a deleted first/middle/last), keeping the chain so it
stays recoverable.

- Deletion (and the replace path of writeFile) now flips block states to their
  deleted forms in place instead of wiping them, and allocation prefers
  never-allocated (0xa0) blocks over deleted ones so deletions survive as long
  as possible.
- A block counts as in use only when it is reachable from a valid first-block
  head, so an orphaned middle/last block (such as one left by an interrupted
  write) is reclaimed as free rather than leaked.
- The directory is committed in an order that survives power loss: a create
  writes the file's 0x51 head last and a delete cuts the head first, so a loss
  mid-commit can only orphan tail blocks (which read as free) and never leaves a
  referenced file pointing at a half-written chain.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mips/psyqo/src/memory-card-filesystem.cpp (1)

373-399: 🗄️ Data Integrity & Integration | 🟠 Major

Unbounded name length in directory entry construction can corrupt metadata fields.

The code at lines 373-399 computes nameLen by scanning m_name to its NUL terminator without any bounds check. Line 391 then copies nameLen bytes into the name field at entry + c_offName. Since ListFiles (line 452) reads exactly 20 bytes from this position and expects the field width to be 20 bytes (matching FileEntry.name[21]), any nameLen > 20 will overflow into adjacent metadata fields. Additionally, if the caller passes an unterminated string via writeFile() or deleteFileBlocking(), the scan at line 374 will read out of bounds.

The writeFile() and deleteFile() API entry points do not validate the name parameter before storing it in m_name. Other code paths in this same file (line 137-139) demonstrate proper validation using c_maxNameLength.

Clamp nameLen to c_maxNameLength before the memcpy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/psyqo/src/memory-card-filesystem.cpp` around lines 373 - 399, The
nameLen variable is computed by scanning m_name without bounds checking, and
when copied to entry + c_offName using __builtin_memcpy, any nameLen exceeding
the field width of 20 bytes will overflow into adjacent metadata fields.
Additionally, if m_name is unterminated, the while loop will read out of bounds.
After computing nameLen (and before the memcpy at line 391), clamp nameLen to
c_maxNameLength to ensure the copied name does not overflow the field width,
matching the validation pattern used elsewhere in this file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/mips/psyqo/src/memory-card-filesystem.cpp`:
- Around line 373-399: The nameLen variable is computed by scanning m_name
without bounds checking, and when copied to entry + c_offName using
__builtin_memcpy, any nameLen exceeding the field width of 20 bytes will
overflow into adjacent metadata fields. Additionally, if m_name is unterminated,
the while loop will read out of bounds. After computing nameLen (and before the
memcpy at line 391), clamp nameLen to c_maxNameLength to ensure the copied name
does not overflow the field width, matching the validation pattern used
elsewhere in this file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 77d2af96-3aad-4062-937a-f42214e4ccfc

📥 Commits

Reviewing files that changed from the base of the PR and between 79a12bc and c162675.

📒 Files selected for processing (2)
  • src/mips/psyqo/memory-card-filesystem.hh
  • src/mips/psyqo/src/memory-card-filesystem.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mips/psyqo/memory-card-filesystem.hh


#include "common/util/sjis-encode-table.h"

// Unicode -> Shift-JIS encoding, the inverse of support/sjis_conv.cc's decoder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be good to add an EASTL version of sjis_conv.h/.cc to psyqo to make it possible to display/printf obtained filenames?

Right now you can't use it as-is, because sjis_conv.h/.cc use which is not available in freestanding mode.

nicolasnoble and others added 4 commits July 1, 2026 03:28
The filesystem could write a save's title and icon and read its payload
back, but there was no way to read the title or icon of an existing
block, so a game couldn't list saves with their names and icons the way
the BIOS manager does.

Add readFileInfo()/readFileInfoBlocking(), filling a FileInfo with the
raw Shift-JIS title field and the icon (palette plus its animation
frames) straight out of the file's first block. The title is handed back
as stored rather than decoded, since that is what the kernel font is
indexed by and it round-trips losslessly with writeFile.

Co-authored-by: Bandwidth <jracek@honzuvkod.dev>
Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
The memory card self-test example now steps through readFileInfo right
after the write: it reads the title and icon back, checks the title
decodes to what was written and the icon matches byte-for-byte, and pops
a modal showing the title read straight off the card.

Co-authored-by: Bandwidth <jracek@honzuvkod.dev>
Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>
The Unicode->Shift-JIS conversion table is about 28kB and was linked into
every program that writes a save, because the title encoder referenced it
unconditionally. Route title encoding through a function pointer that
defaults to a small ASCII-only encoder (printable ASCII promoted to its
fullwidth form via a 190-byte subset of the table); registerFullTitleEncoder()
swaps in the full converter. With --gc-sections the full table now only links
when a program actually registers it.

Signed-off-by: Nicolas 'Pixel' Noble <nicolas@nobis-crew.org>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/mips/common/util/sjis-encode.h (1)

38-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated encode/emit loop between utf8ToSjis and utf8ToSjisTitle.

Both functions share the exact same decode/lookup/emit-and-bound-check logic (lines 41-53 and 66-83); the only difference is the fullwidth cp remap on lines 69-73. Consider factoring the shared loop into a helper parameterized by an optional cp-transform, to avoid having to fix the same bug twice in the future.

♻️ Suggested consolidation
+template <typename Transform>
+static inline uint32_t encodeSjisImpl(uint8_t* dst, uint32_t dstSize, const char* src, Transform transform) {
+    uint32_t srcLen = 0;
+    while (src[srcLen]) srcLen++;
+    uint32_t in = 0, out = 0;
+    while (in < srcLen && out < dstSize) {
+        uint16_t cp = utf8Decode(src, srcLen, &in);
+        cp = transform(cp);
+        uint16_t sjis = unicodeToSjis(cp);
+        if (sjis == 0 && cp != 0) sjis = '?';
+        if (sjis > 0xff) {
+            if (out + 2 > dstSize) break;
+            dst[out++] = sjis >> 8;
+            dst[out++] = sjis & 0xff;
+        } else {
+            dst[out++] = sjis & 0xff;
+        }
+    }
+    return out;
+}
+
 static inline uint32_t utf8ToSjis(uint8_t* dst, uint32_t dstSize, const char* src) {
-    ...
+    return encodeSjisImpl(dst, dstSize, src, [](uint16_t cp) { return cp; });
 }
 
 static inline uint32_t utf8ToSjisTitle(uint8_t* dst, uint32_t dstSize, const char* src) {
-    ...
+    return encodeSjisImpl(dst, dstSize, src, [](uint16_t cp) {
+        if (cp == ' ') return uint16_t(0x3000);
+        if (cp >= 0x21 && cp <= 0x7e) return uint16_t(0xff00 + (cp - 0x20));
+        return cp;
+    });
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mips/common/util/sjis-encode.h` around lines 38 - 85, The encode/emit
loop is duplicated between utf8ToSjis and utf8ToSjisTitle, with only the
title-specific fullwidth codepoint remap differing. Refactor the shared UTF-8
decode, unicodeToSjis lookup, fallback, and bounded write logic into a common
helper, and let utf8ToSjisTitle supply the remap behavior while utf8ToSjis uses
the default path. Keep the existing public function names and preserve their
current output semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/mips/common/util/sjis-encode.h`:
- Around line 38-85: The encode/emit loop is duplicated between utf8ToSjis and
utf8ToSjisTitle, with only the title-specific fullwidth codepoint remap
differing. Refactor the shared UTF-8 decode, unicodeToSjis lookup, fallback, and
bounded write logic into a common helper, and let utf8ToSjisTitle supply the
remap behavior while utf8ToSjis uses the default path. Keep the existing public
function names and preserve their current output semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2bf3c4b2-4143-4b21-8825-b1386b6b9964

📥 Commits

Reviewing files that changed from the base of the PR and between ab00cbf and e41e613.

📒 Files selected for processing (7)
  • src/mips/common/util/sjis-encode.h
  • src/mips/common/util/sjis-fullwidth-ascii.h
  • src/mips/common/util/sjis-title-encoder.h
  • src/mips/common/util/utf8-decode.h
  • src/mips/psyqo/src/memory-card-filesystem.cpp
  • src/mips/psyqo/src/sjis-full-title-encoder.cpp
  • src/mips/psyqo/src/sjis-title-encoder.cpp
✅ Files skipped from review due to trivial changes (1)
  • src/mips/common/util/sjis-fullwidth-ascii.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mips/psyqo/src/memory-card-filesystem.cpp

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants